{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "ein.tags": "worksheet-0",
    "slideshow": {
     "slide_type": "-"
    }
   },
   "source": [
    "# Flask大型应用程序结构\n",
    "\n",
    "Flask并不强制要求大型项目使用特定的组织方式，程序结构的组织方式完全由开发者决定。\n",
    "\n",
    "下面我们将介绍一种使用包和模块组织大型程序的方式。\n",
    "\n",
    "\n",
    "## 项目结构\n",
    "\n",
    "Flask程序的基本结构如下：\n",
    "\n",
    "    │Project\n",
    "    ├── README.md\n",
    "    ├── app\n",
    "    │   ├── __init__.py\n",
    "    │   ├── api\n",
    "    │   ├── auth\n",
    "    │   ├── decorators.py\n",
    "    │   ├── main\n",
    "    │   │   ├── __init__.py\n",
    "    │   │   ├── errors.py\n",
    "    │   │   ├── forms.py\n",
    "    │   │   └── views.py\n",
    "    │   ├── models.py\n",
    "    │   ├── static\n",
    "    │   └── templates\n",
    "    ├── config.py\n",
    "    ├── manage.py\n",
    "    ├── migrations\n",
    "    ├── tests\n",
    "    ├── prod\n",
    "    ├── requirements\n",
    "    └── utils\n",
    "\n",
    "顶级文件夹：\n",
    "\n",
    "-   Flask程序一般都保存在名为`app`的包中；\n",
    "-   `migrations`文件夹包含数据库迁移脚本；\n",
    "-   单元测试编写在`tests`包中；\n",
    "-   `prod`文件夹包含生产环境部署配置文件；\n",
    "-   `utils`包中放置工具函数或者可独立使用的库；\n",
    "-   `requirements`文件夹包含所有依赖包（不同环境），用来生成虚拟环境。\n",
    "\n",
    "一些文件：\n",
    "\n",
    "-   `config.py`存储配置；\n",
    "-   `manage.py`用于指定`flask`命令的运行的程序和其他任务命令；\n",
    "-   `README.md`项目介绍。\n",
    "\n",
    "下面几节介绍如何把`hello.py`程序转换成上面这种结构。\n",
    "\n",
    "\n",
    "## 配置选项\n",
    "\n",
    "程序经常需要设定多个配置，比如开发、测试和生产环境要使用不同的数据库。\n",
    "\n",
    "下面的代码展示了使用层次结构的配置类。\n",
    "\n",
    "```python\n",
    "# config.py\n",
    "\n",
    "import os\n",
    "basedir = os.path.abspath(os.path.dirname(__file__))\n",
    "\n",
    "\n",
    "class Config(object):\n",
    "    PROJECT_DIR = basedir\n",
    "    SECRET_KEY = (os.environ.get('SECRET_KEY') or\n",
    "                  '44617457d542163d10ada66726b31ef80a88ac1a41013ea5')\n",
    "\n",
    "    PEEWEE_MODELS_MODULE = 'app.models'\n",
    "\n",
    "    @classmethod\n",
    "    def init_app(cls, app):\n",
    "        pass\n",
    "\n",
    "\n",
    "class DevelopmentConfig(Config):\n",
    "    DEBUG = True\n",
    "    PEEWEE_DATABASE_URI = (\n",
    "        os.environ.get('DEV_DATABASE_URL') or\n",
    "        'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')\n",
    "    )\n",
    "\n",
    "\n",
    "class TestingConfig(Config):\n",
    "    TESTING = True\n",
    "    PEEWEE_DATABASE_URI = (\n",
    "        os.environ.get('DEV_DATABASE_URL') or\n",
    "        'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')\n",
    "    )\n",
    "\n",
    "\n",
    "class ProductionConfig(Config):\n",
    "    DEBUG = False\n",
    "\n",
    "    PEEWEE_DATABASE_URI = (\n",
    "        os.environ.get('PROD_DATABASE_URL') or\n",
    "        'sqlite:///' + os.path.join(basedir, 'data.sqlite')\n",
    "    )\n",
    "\n",
    "\n",
    "config = {\n",
    "    'development': DevelopmentConfig,\n",
    "    'testing': TestingConfig,\n",
    "    'production': ProductionConfig,\n",
    "\n",
    "    'default': DevelopmentConfig\n",
    "}\n",
    "```\n",
    "\n",
    "基类`Config`中包含通用配置，子类分别定义专用的配置。如果需要，你还可添加其他配置类。\n",
    "\n",
    "为了让配置方式更灵活且更安全，某些配置可以从环境变量中导入。例如，`SECRET_KEY`的值，可以在环境中设定，但系统也提供了一个默认值，以防环境中没有定义。\n",
    "\n",
    "基类`Config`中变量`PEEWEE_MODELS_MODULE`是 **Flask-PW** 扩展的设置选项，用来指定程序的数据模型定义模块的路径。\n",
    "\n",
    "在3个子类中，`PEEWEE_DATABASE_URI`变量都被指定了不同的值。这样程序在不同的配置环境中运行就可使用不同的数据库。\n",
    "\n",
    "配置类可以定义`init_app()`类方法，其参数是程序实例。在这个方法中，可以执行对当前环境的配置初始化。现在，基类`Config`中的`init_app()`方法为空。\n",
    "\n",
    "配置脚本末尾，`config`字典中注册了不同的配置环境，而且还注册了一个默认配置（开发环境）。\n",
    "\n",
    "\n",
    "## 程序包\n",
    "\n",
    "程序包用来保存程序的所有代码、模板和静态文件。可以把这个包直接称为`app`（应用），如果有需求，也可使用一个程序专用名字。\n",
    "\n",
    "`templates`和`static`文件夹是程序包的一部分，因此到后面这两个文件夹被移到了`app`中。\n",
    "\n",
    "数据库模型被移到了这个包中，保存为`app/models.py`。\n",
    "\n",
    "\n",
    "### 程序工厂函数\n",
    "\n",
    "为了动态修改配置或在不同的配置环境中运行程序，可以延迟创建程序实例，将其创建过程移到可显式调用的工厂函数中。这样不仅可以给脚本留出配置程序的时间，还能够创建多个程序实例，这些实例有时在测试中非常有用。\n",
    "\n",
    "程序的工厂函数在`app`包的构造文件中定义，如下所示：\n",
    "\n",
    "```python\n",
    "# app/__init__.py\n",
    "from flask import Flask\n",
    "\n",
    "from flask_bootstrap import Bootstrap\n",
    "from flask_moment import Moment\n",
    "from flask_pw import Peewee\n",
    "\n",
    "from config import config\n",
    "\n",
    "\n",
    "bootstrap = Bootstrap()\n",
    "moment = Moment()\n",
    "db = Peewee()\n",
    "\n",
    "\n",
    "def create_app(config_name):\n",
    "    app = Flask(__name__)\n",
    "    app.config.from_object(config[config_name])\n",
    "    config[config_name].init_app(app)\n",
    "\n",
    "    bootstrap.init_app(app)\n",
    "    moment.init_app(app)\n",
    "    db.init_app(app)\n",
    "    app.cli.add_command(db.cli, 'db')\n",
    "\n",
    "    # 附加路由和自定义的错误页面\n",
    "\n",
    "    return app\n",
    "```\n",
    "\n",
    "工厂函数返回创建的程序示例，现在工厂函数创建的程序还不完整，因为没有路由和自定义的错误页面处理程序。\n",
    "\n",
    "\n",
    "### Flask蓝图\n",
    "\n",
    "在单脚本程序中，程序实例存在于全局作用域中，路由可以直接使用`app.route`装饰器定义。但现在程序在运行时创建，只有调用`create_app()`之后才能使用`app.route`装饰器，这时定义路由就太晚了。另外，自定义的错误页面处理程序也面临相同的困难，因为错误页面处理程序使用`app.errorhandler`装饰器定义。\n",
    "\n",
    "Flask使用蓝图（Blueprint）提供了更好地解决方法。\n",
    "\n",
    "蓝图实现了应用的模块化，其和程序类似，也可以定义路由，在蓝图中定义的路由处于休眠状态，直到蓝图注册到程序上后，路由才真正成为程序的一部分。蓝图通常作用于相同的URL前缀，比如`/user/:id`、`/user/profile`这样的地址，都以`/user`开头，那么它们就可以放在一个模块中。\n",
    "\n",
    "使用蓝图让应用层次清晰，开发者可以更容易地开发和维护项目。\n",
    "\n",
    "看一个简单的示例：\n",
    "\n",
    "```python\n",
    "# user.py\n",
    "from flask import Blueprint\n",
    "\n",
    "bp = Blueprint('user', __name__, url_prefix='/user')\n",
    "\n",
    "\n",
    "@bp.route('/')\n",
    "def index():\n",
    "    return 'User\"s index page'\n",
    "```\n",
    "\n",
    "通过实例化一个`Blueprint`类对象来创建蓝图。这个构造函数有两个必须指定的参数： 蓝图的名字和蓝图所在的包或模块。和程序一样，大多数情况下第二个参数使用 Python 的`__name__`变量即可。`url_prefix`是可选参数，如果设定，注册蓝图后其中 定义的所有路由都会加上指定的前缀，此例中为`/user`。\n",
    "\n",
    "再看主程序：\n",
    "\n",
    "```python\n",
    "# app.py\n",
    "from flask import Flask\n",
    "import user\n",
    "\n",
    "\n",
    "app = Flask(__name__)\n",
    "app.register_blueprint(user.bp)\n",
    "```\n",
    "\n",
    "使用`register_blueprint`注册模块，如果想去掉模块只需要去掉对应的注册语句即可。\n",
    "\n",
    "为了获得最大的灵活性，程序包中创建了一个子包，用于保存蓝图。下面的示例是这个子包的构造文件，蓝图就创建于此。\n",
    "\n",
    "```python\n",
    "# app/main/__init__.py\n",
    "from flask import Blueprint\n",
    "\n",
    "main = Blueprint('main', __name__)\n",
    "\n",
    "from . import views, errors\n",
    "```\n",
    "\n",
    "程序的路由保存在包里的`app/main/views.py`模块中，而错误处理程序保存在`app/main/errors.py`模块中。导入这两个模块就能把路由和错误处理程序与蓝图关联起来。\n",
    "\n",
    "**⚠️ 这些模块必须在`app/main/__init__.py`脚本的末尾导入，** 为了避免循环导入依赖，因为在`views.py`和`errors.py`中还要导入蓝图`main`。\n",
    "\n",
    "蓝图在工厂函数`create_app()`中注册到程序上，如下所示：\n",
    "\n",
    "```python\n",
    "# app/__init__.py\n",
    "def create_app(config_name):\n",
    "    # ...\n",
    "\n",
    "    from .main import main as main_blueprint\n",
    "    app.register_blueprint(main_blueprint)\n",
    "\n",
    "    return app\n",
    "```\n",
    "\n",
    "下面是错误处理程序：\n",
    "\n",
    "```python\n",
    "# app/main/errors.py\n",
    "from flask import render_template\n",
    "\n",
    "from . import main\n",
    "\n",
    "\n",
    "@main.app_errorhandler(404)\n",
    "def page_not_found(e):\n",
    "    return render_template('404.html'), 404\n",
    "\n",
    "\n",
    "@main.app_errorhandler(500)\n",
    "def internal_server_error(e):\n",
    "    return render_template('500.html'), 500\n",
    "```\n",
    "\n",
    "在蓝图中编写错误处理程序稍有不同，如果使用`errorhandler`装饰器，那么只有蓝图中的 错误才能触发处理程序。要想注册程序全局的错误处理程序，必须使用`app_errorhandler`。\n",
    "\n",
    "在蓝图中定义程序路由：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "from datetime import datetime\n",
    "\n",
    "from flask import render_template, session, redirect, url_for\n",
    "\n",
    "from . import main\n",
    "from .forms import NameForm\n",
    "from .. import db\n",
    "from ..models import User\n",
    "\n",
    "\n",
    "@main.route('/', methods=['GET', 'POST'])\n",
    "def index():\n",
    "    form = NameForm()\n",
    "    if form.validate_on_submit():\n",
    "        # ...\n",
    "        return redirect(url_for('.index'))\n",
    "    return render_template('index.html',\n",
    "                           form=form, name=session.get('name'),\n",
    "                           known=session.get('known', False),\n",
    "                           current_time=datetime.utcnow())\n",
    "```\n",
    "\n",
    "在蓝图中编写视图函数主要有两点不同：\n",
    "\n",
    "1.  和前面的错误处理程序一样，路由装饰器由蓝图提供；\n",
    "2.  `url_for()`函数的用法不同。\n",
    "\n",
    "    `url_for()`函数的第一个参数是路由的端点名，在程序的路由中，默认为视图函数的名字。例如，在单脚本程序中，`index()`视图函数的URL可使用`url_for('index')`获取。\n",
    "\n",
    "    蓝图中就不一样了，Flask会为蓝图中的全部端点加上一个命名空间，这样就可以在不 同的蓝图中使用相同的端点名定义视图函数，而不会产生冲突。命名空间就是蓝图的名字（`Blueprint`构造函数的第一个参数），所以视图函数`index()`注册的端点名是`main.index`，其URL使用`url_for('main.index')`获取。\n",
    "\n",
    "    `url_for()`函数还支持一种简写的端点形式，在蓝图中可以省略蓝图名，例如`url_for('.index')`。在这种写法中，命名空间是当前请求所在的蓝图。这意味着同一蓝图中的重定向可以使用简写形式，但跨蓝图的重定向必须使用带有命名空间 的端点名。\n",
    "\n",
    "此外，表单对象也要移到蓝图中，保存于`app/main/forms.py`模块。\n",
    "\n",
    "\n",
    "## 命令行脚本\n",
    "\n",
    "顶级文件夹中的`manage.py`用于指定`flask`命令的运行的程序和其他任务命令。\n",
    "\n",
    "```python\n",
    "# manage.py\n",
    "import os\n",
    "import sys\n",
    "\n",
    "import click\n",
    "\n",
    "from app import create_app, db\n",
    "\n",
    "\n",
    "app = create_app(os.getenv('FLASK_CONFIG') or 'default')\n",
    "\n",
    "\n",
    "@app.cli.command()\n",
    "def create_tables():\n",
    "    db.database.create_tables(db.models)\n",
    "```\n",
    "\n",
    "这个脚本先创建程序。如果已经定义了环境变量`FLASK_CONFIG`，则从中读取配置名；否则使用默认配置。\n",
    "\n",
    "另外，添加了一个命令`create_tables`，用来根据定义的模型创建数据库表。\n",
    "\n",
    "\n",
    "## 需求文件\n",
    "\n",
    "程序中必须包含一个`requirements.txt`文件，用于记录所有依赖包及其精确的版本号。\n",
    "\n",
    "这里我们使用一个工具 [pip-tools](https://github.com/jazzband/pip-tools) 来进行依赖包管理。\n",
    "\n",
    "**pip-tools = pip-compile + pip-sync**\n",
    "\n",
    "有两个文件来进行依赖包管理：\n",
    "\n",
    "-   `requirements.in`（手动创建）包含了项目中直接使用到的包\n",
    "-   `requirements.txt`（通过`pip-compile requirements.in`创建）包含所有包包括依赖包\n",
    "\n",
    "项目中如果用到新的依赖包，将包的名字添加到`requirements.in`中，然后使用`pip-compile requirements.in`生成`requirements.txt`文件，再使用`pip-sync requirements.txt`安装所有包。\n",
    "\n",
    "更多关于`pip-tools`的使用介绍，请参考其文档。\n",
    "\n",
    "下面所示为`requirements.in`文件的内容：\n",
    "\n",
    "    flask\n",
    "    flask-bootstrap\n",
    "    flask-wtf\n",
    "    flask-moment\n",
    "    flask-pw\n",
    "    pip-tools\n",
    "\n",
    "\n",
    "## 单元测试\n",
    "\n",
    "为了演示，编写两个简单的测试：\n",
    "\n",
    "```python\n",
    "# tests/test_basics.py\n",
    "import unittest\n",
    "\n",
    "from flask import current_app\n",
    "from app import create_app, db\n",
    "\n",
    "\n",
    "class BasicsTestCase(unittest.TestCase):\n",
    "    def setUp(self):\n",
    "        self.app = create_app('testing')\n",
    "        self.app_context = self.app.app_context()\n",
    "        self.app_context.push()\n",
    "        # create all db tables\n",
    "\n",
    "    def tearDown(self):\n",
    "        # drop all db tables\n",
    "        self.app_context.pop()\n",
    "\n",
    "    def test_app_exists(self):\n",
    "        self.assertFalse(current_app is None)\n",
    "\n",
    "    def test_app_is_testing(self):\n",
    "        self.assertTrue(current_app.config['TESTING'])\n",
    "```\n",
    "\n",
    "这个测试使用 Python 标准库中的`unittest`包编写。`setUp()`和`tearDown()`方法分别在各测试前后运行，并且名字以`test_`开头的函数 都作为测试执行。\n",
    "\n",
    "如果想进一步了解如何使用Python的`unittest`包编写测试，请阅读[官方文档](https://docs.python.org/3/library/unittest.html)。\n",
    "\n",
    "`setUp()`方法尝试创建一个测试环境，类似于运行中的程序。首先，使用测试配置创建程序，然后激活上下文。这一步的作用是确保能在测试中使用`current_app`，像普通请求一样。然后创建一个全新的数据库。数据库和程序上下文在`tearDown()`方法中删除。\n",
    "\n",
    "第一个测试确保程序实例存在。第二个测试确保程序在测试配置中运行。若想把`tests`文件夹作为包使用，需要添加`tests/__init__.py`文件，这个文件可以为空，因为`unittest`包会扫描所有模块并查找测试。\n",
    "\n",
    "为了运行单元测试，可以在`manage.py`脚本中添加一个自定义命令`test`。\n",
    "\n",
    "```python\n",
    "# manage.py\n",
    "@app.cli.command()\n",
    "def test():\n",
    "    \"\"\"Run the unit tests.\"\"\"\n",
    "    import unittest\n",
    "    tests = unittest.TestLoader().discover('tests')\n",
    "    unittest.TextTestRunner(verbosity=2).run(tests)\n",
    "```\n",
    "\n",
    "修饰函数名就是命令名，函数的文档字符串会显示在帮助消息中。`test()`函数的定义体中调用了`unittest`包提供的测试运行函数。\n",
    "\n",
    "单元测试可使用下面的命令运行：\n",
    "\n",
    "```sh\n",
    "(flaskr_env3) $ export FLASK_APP=manage.py\n",
    "(flaskr_env3) $ flask test\n",
    "test_app_exists (test_basics.BasicsTestCase) ... ok\n",
    "test_app_is_testing (test_basics.BasicsTestCase) ... ok\n",
    "\n",
    "----------------------------------------------------------------------\n",
    "Ran 2 tests in 0.003s\n",
    "\n",
    "OK\n",
    "```\n",
    "\n",
    "**🔖 执行`git checkout 7a`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 新的创建数据库命令\n",
    "\n",
    "Flask-PW 提供了一个`db`命令，下面的代码展示了如何将新的创建数据库命令`createtables`添加`db`命令下面，作为其子命令使用。\n",
    "\n",
    "```python\n",
    "# manage.py\n",
    "from flask.cli import with_appcontext\n",
    "\n",
    "from flask_pw import BaseSignalModel\n",
    "\n",
    "\n",
    "@db.cli.command('createtables', short_help='Create database tables.')\n",
    "@click.option('--safe', default=False, is_flag=True,\n",
    "              help=('Check first whether the table exists '\n",
    "                    'before attempting to create it.'))\n",
    "@click.argument('models', nargs=-1, type=click.UNPROCESSED)\n",
    "@with_appcontext\n",
    "def create_tables(models, safe):\n",
    "    from importlib import import_module\n",
    "    from flask.globals import _app_ctx_stack\n",
    "    app = _app_ctx_stack.top.app\n",
    "    if models:\n",
    "        pw_models = []\n",
    "\n",
    "        module = import_module(app.config['PEEWEE_MODELS_MODULE'])\n",
    "        for mod in models:\n",
    "            model = getattr(module, mod)\n",
    "            if not isinstance(model, BaseSignalModel):\n",
    "                continue\n",
    "            pw_models.append(model)\n",
    "        if pw_models:\n",
    "            db.database.create_tables(pw_models, safe)\n",
    "        return\n",
    "    db.database.create_tables(db.models, safe)\n",
    "```\n",
    "\n",
    "**🔖 执行`git checkout 7b`签出程序的这个版本。**"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.2"
  },
  "name": "7-large-app-structure.ipynb",
  "toc": {
   "colors": {
    "hover_highlight": "#ddd",
    "running_highlight": "#FF0000",
    "selected_highlight": "#ccc"
   },
   "moveMenuLeft": true,
   "nav_menu": {
    "height": "192px",
    "width": "252px"
   },
   "navigate_menu": true,
   "number_sections": false,
   "sideBar": true,
   "threshold": 4,
   "toc_cell": false,
   "toc_section_display": "block",
   "toc_window_display": false,
   "widenNotebook": false
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
